"""
Bilibili 视频弹幕聚合分析脚本

功能：
1. 根据关键词搜索指定数量的B站视频。
2. 爬取这些视频的所有弹幕。
3. 聚合所有弹幕数据。
4. 对聚合数据进行清洗和中文分词。
5. 生成词云图 (WordCloud)。
6. 统计高频词汇并保存到 TXT。
7. 统计高频词汇并保存到 Excel (Top 8 高亮)。
8. 保存所有原始弹幕数据。
"""

# 核心库
import requests
from bs4 import BeautifulSoup
import re
import time
import openpyxl

# 数据处理与可视化库
from wordcloud import WordCloud
import jieba
from PIL import Image
import numpy as np
import matplotlib.pyplot as plt


# --- 全局配置 ---

# 在这里添加不希望出现在词云和词频统计中的词汇
# 使用集合 (set) 类型，查询效率更高
STOPWORDS = {
    "666", "三连", "点赞", "投币", "收藏", "关注", "up", "UP", "主播",
    "哈哈哈", "哈哈哈哈", "h", "hh", "这个", "那个", "一个", "什么", "怎么",
    "6666", "2333", "已三连", "已三连求"
}


# --- 核心功能函数 ---

def get_cid(bvid):
    """
    根据 Bilibili 视频的 BVID 获取其 CID (弹幕服务器ID)。
    CID 是获取弹幕 XML 文件所必需的。

    Args:
        bvid (str): 视频的 BVID，例如 "BV1xx411c7mD"。

    Returns:
        int: 视频的 CID。

    Raises:
        Exception: 如果 API 请求失败或返回错误信息。
    """
    # B站的 Web 视频详情 API
    url = f"https://api.bilibili.com/x/web-interface/view?bvid={bvid}"
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
        "Referer": f"https://www.bilibili.com/video/{bvid}",
    }

    try:
        response = requests.get(url, headers=headers, timeout=10)
        response.raise_for_status()  # 如果状态码不是 200，则引发 HTTPError
        data = response.json()

        if data.get("code") == 0:
            # API 访问成功，返回 CID
            return data["data"]["cid"]
        else:
            # API 返回了错误信息 (例如 "稿件不可见")
            raise Exception(f"API返回错误: {data.get('message')}")

    except Exception as e:
        # 处理请求超时、JSON解码失败等所有异常
        raise Exception(f"获取cid时出错 (BVID: {bvid}): {str(e)}")


def fetch_danmaku(cid, bvid):
    """
    根据 CID 获取视频的弹幕 XML 文件，并解析出所有弹幕文本。

    Args:
        cid (int): 视频的 CID。
        bvid (str): 视频的 BVID (仅用于设置 Referer 头)。

    Returns:
        list[str]: 包含所有弹幕文本的列表。

    Raises:
        Exception: 如果 XML 请求或解析失败。
    """
    # B站弹幕文件 API
    url = f"https://comment.bilibili.com/{cid}.xml"
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
        "Referer": f"https://www.bilibili.com/video/{bvid}",
    }

    try:
        response = requests.get(url, headers=headers, timeout=10)
        response.raise_for_status()  # 确保请求成功
        # B站的XML编码可能是 'ISO-8859-1' 或 'utf-8'，但 response.content 会返回原始字节流
        # BeautifulSoup 配合 lxml-xml 解析器能很好地处理
        soup = BeautifulSoup(response.content, "lxml-xml")  # 使用 lxml-xml 解析器
        
        # 查找所有 <d> 标签，它们的文本内容就是弹幕
        return [d.text for d in soup.find_all("d")]

    except Exception as e:
        raise Exception(f"获取弹幕时出错 (CID: {cid}): {str(e)}")


def clean_text(text):
    """
    清洗单条弹幕文本。
    - 去除所有非字母、非数字、非中文字符。
    - 合并多个连续的空白符。

    Args:
        text (str): 原始弹幕文本。

    Returns:
        str: 清洗后的文本。
    """
    # [^\w\s] 匹配所有非单词字符(字母,数字,下划线)和非空白字符
    # 在中文语境下，\w 也能匹配中文字符
    text = re.sub(r'[^\w\s]', '', text)  # 去除符号
    text = re.sub(r'\s+', ' ', text)   # 合并连续的空格、换行符等
    return text.strip()  # 去除首尾的空格


def generate_wordcloud(text_list, output_path="聚合词云.jpg"):
    """
    使用 jieba 分词，根据聚合的弹幕列表生成词云图。

    Args:
        text_list (list[str]): 包含所有清洗后弹幕的列表。
        output_path (str): 词云图片保存路径。
    """
    print("开始进行jieba分词（弹幕量大时可能需要几分钟）...")
    
    # 将所有弹幕合并为一个长字符串，用空格隔开
    text = " ".join(text_list)
    # 使用 jieba 进行分词
    words = " ".join(jieba.cut(text))

    # --- 词云遮罩 (Mask) 处理 ---
    mask_image = None
    try:
        # 尝试打开名为 "369.png" 的图片作为遮罩
        # !! 注意：请确保此图片文件与 .py 脚本在同一目录下 !!
        img = Image.open("369.png")
        # 转换为灰度图 (L模式)
        img_gray = img.convert("L")
        # 转换为 numpy 数组
        mask_array = np.array(img_gray)
        
        # --- 阈值处理，确保遮罩清晰 ---
        # 假设图片有白色背景和黑色主体
        threshold = 128  # 阈值
        # 低于阈值 (暗色) 的像素设为 0 (词云绘制区域)
        mask_array[mask_array < threshold] = 0
        # 高于等于阈值 (亮色) 的像素设为 255 (词云忽略区域)
        mask_array[mask_array >= threshold] = 255
        mask_image = mask_array
        print(" -> 成功加载遮罩图片 '369.png'。")

    except FileNotFoundError:
        print("错误：未找到掩码图片 '369.png'！将生成默认矩形词云。")
        mask_image = None
    except Exception as e:
        print(f"处理遮罩图片时出错: {e}。将生成默认矩形词云。")
        mask_image = None

    # --- WordCloud 对象配置 ---
    wc = WordCloud(
        # !! 注意：请确保 'msyh.ttc' (微软雅黑) 字体文件存在于系统或脚本目录中 !!
        font_path="msyh.ttc",
        width=800,
        height=800,
        mask=mask_image,            # 应用遮罩
        background_color="white",   # 强制画布背景为白色
        stopwords=STOPWORDS,        # 设置停用词
        contour_width=0,            # 遮罩轮廓线宽度 (设置为0则不显示)
        contour_color="gray",       # 轮廓线颜色
        max_words=200,              # 词云显示的最大词汇量
        collocations=False,         # 避免生成 "词组" (例如 "这个" 和 "主播" 组合成 "这个主播")
    )

    print("正在生成词云图片...")
    wc.generate(words)
    wc.to_file(output_path)

    print(f"聚合词云已保存到：{output_path}")
    
    # (可选) 如果希望在脚本运行时自动显示图片
    # plt.imshow(wc, interpolation='bilinear')
    # plt.axis("off")
    # plt.show()


def save_word_freq_to_txt(text_list, output_path="聚合词频数据.txt"):
    """
    统计词频，并将结果（Top 200）保存到 TXT 文件。

    Args:
        text_list (list[str]): 聚合的弹幕列表。
        output_path (str): TXT 文件保存路径。
    """
    print("正在统计词频 (TXT)...")
    text = " ".join(text_list)
    words = jieba.cut(text)
    
    word_freq = {}
    for word in words:
        # 过滤掉单个字的词 (意义不大) 和 停用词
        if len(word) > 1 and word not in STOPWORDS:
            word_freq[word] = word_freq.get(word, 0) + 1

    # 按词频降序排序
    sorted_freq = sorted(word_freq.items(), key=lambda x: x[1], reverse=True)

    # 写入文件
    try:
        with open(output_path, "w", encoding="utf-8") as f:
            f.write("词汇\t出现频率\n")
            # 仅保存前 200 个高频词
            for word, freq in sorted_freq[:200]:
                f.write(f"{word}\t{freq}\n")
                
        print(f"聚合词频数据已保存到：{output_path}")
        
    except Exception as e:
        print(f"写入 TXT 文件时出错: {e}")


def save_word_freq_to_excel(text_list, output_path="聚合词频数据.xlsx"):
    """
    统计词频，并将完整结果保存到 Excel 文件。
    前 8 名的行将被高亮显示。

    Args:
        text_list (list[str]): 聚合的弹幕列表。
        output_path (str): Excel 文件保存路径。
    """
    print("正在统计词频并准备写入 Excel...")
    text = " ".join(text_list)
    words = jieba.cut(text)
    
    word_freq = {}
    for word in words:
        # 过滤掉单个字的词 和 停用词
        if len(word) > 1 and word not in STOPWORDS:
            word_freq[word] = word_freq.get(word, 0) + 1

    sorted_freq = sorted(word_freq.items(), key=lambda x: x[1], reverse=True)

    # --- 写入 Excel ---
    try:
        wb = openpyxl.Workbook()
        ws = wb.active
        ws.title = "词频统计"

        # 写入表头
        ws.append(["排名", "词汇", "出现频率"])

        # 定义前 8 名高亮样式 (浅黄色)
        highlight_fill = openpyxl.styles.PatternFill(
            start_color="FFFFE0", 
            end_color="FFFFE0", 
            fill_type="solid"
        )

        # 写入数据
        for i, (word, freq) in enumerate(sorted_freq, 1):
            # 写入行数据 [排名, 词汇, 频率]
            ws.append([i, word, freq])

            # 高亮前 8 名
            if i <= 8:
                # i+1 是因为 Excel 行索引从 1 开始，且第 1 行是表头
                current_row_index = i + 1
                # 遍历 A, B, C 三列的单元格
                for cell in ws[f"A{current_row_index}:C{current_row_index}"][0]:
                    cell.fill = highlight_fill

        # 调整列宽，使 "词汇" 列更宽
        ws.column_dimensions['B'].width = 30
        ws.column_dimensions['C'].width = 15
        ws.column_dimensions['A'].width = 8

        wb.save(output_path)
        print(f"聚合词频数据已保存到 Excel：{output_path} (前 8 名已高亮)")

        # --- 在控制台打印 Top 8 词汇 ---
        top_8_words = [item[0] for item in sorted_freq[:8]]
        print(f"--- 词频统计 Top 8 词汇 ---")
        for i, word in enumerate(top_8_words, 1):
            print(f"Top {i}: {word}")
        print("--------------------------")

    except Exception as e:
        print(f"写入 Excel 时出错: {e}")


def save_danmaku_to_file(danmaku_list, file_path="聚合弹幕数据.txt"):
    """
    保存所有原始弹幕到文件，每条带编号。

    Args:
        danmaku_list (list[str]): 聚合的弹幕列表。
        file_path (str): 保存路径。
    """
    try:
        with open(file_path, "w", encoding="utf-8") as f:
            for i, danmaku in enumerate(danmaku_list, 1):
                f.write(f"[{i}] {danmaku}\n")
        print(f"所有聚合弹幕已保存到文件：{file_path}")
    except Exception as e:
        print(f"保存原始弹幕时出错: {e}")


def search_for_videos(keyword, max_videos=300, search_delay_seconds=2):
    """
    使用 B站 Web 端搜索 API 搜索视频，获取 BVID 列表。

    Args:
        keyword (str): 搜索关键词。
        max_videos (int): 目标获取的 BVID 数量上限。
        search_delay_seconds (int): 每次翻页搜索的延时（秒）。

    Returns:
        list[str]: 收集到的 BVID 列表。
    """
    bvid_list = []
    page = 1

    # 这是B站Web端搜索的内部API (类型=视频)
    search_api_url = "https://api.bilibili.com/x/web-interface/search/type"

    # !! 关键：必须提供有效的 Cookie，否则 API 无法访问或返回数据不全 !!
    # !! 您的 Cookie 已硬编码在此处。注意：Cookie 会过期，过期后脚本将失效 !!
    cookie_str = (
        "buvid4=30F35F28-6227-6045-41CF-BECE4BCA0D0D23089-023081617-erCytIVHUKPFRKoZvg0U1A%3D%3D; "
        "buvid_fp_plain=undefined; enable_web_push=DISABLE; enable_feed_channel=ENABLE; "
        "__51vcke__3G5Pk0eEh7wEuiuP=97abad20-4b34-5431-a7e9-6c432dc872cf; __51vuft__3G5Pk0eEh7wEuiuP=1741957977251; "
        "LIVE_BUVID=AUTO8617455086289655; DedeUserID=175483566; DedeUserID__ckMd5=a442fbc9cba6b520; "
        "header_theme_version=OPEN; theme-tip-show=SHOWED; theme-avatar-tip-show=SHOWED; "
        "fingerprint=2e79314c0b5a39902de0a7fc68f0bd9b; buvid_fp=2e79314c0b5a39902de0a7fc68f0bd9b; "
        "PVID=1; _uuid=3665F36F-B7E7-B266-D329-F71AC777FB9399134infoc; buvid3=6C9AE64E-CADC-7F4E-E830-9B913248430552838infoc; "
        "b_nut=1758440352; __51uvsct__3G5Pk0eEh7wEuiuP=281; rpdid=|(RYkm|YJ|~0J'u~YuR~RkRu; home_feed_column=5; "
        "CURRENT_QUALITY=80; browser_resolution=2327-1235; "
        "bili_ticket=eyJhbGciOiJIUzI1NiIsImtpZCI6InMwMyIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NjI1OTgwMTIsImlhdCI6MTc2MjMzODc1MiwicGx0IjotMX0.9Z-Ksi9vPNfAkoVJRY2mPARes216Oc4EONBh5FTVgM; "
        "bili_ticket_expires=1762597952; SESSDATA=799f1706%2C1777890813%2C1256d%2Ab2CjCGXRfzA0TXjT76gO7_b9mNk-zr8tMgYHcQe1pvnF-9Pz_PSIWgfXmxe19YzUW58tESVmhSMEVnQVR3elJiNXpkRm1NV1ZZMjM2UzBpQS1JdVNyTWc2aXcyTVdTQWtISmxkTUhXNl9TYXZLUmthMEpnQjFSX0phWWZ6anBwQ1BZUlBtRnNPWGtBIIEC; "
        "bili_jct=8b01f49b8b8703bacd7d5c0d18328f5d; bp_t_offset_175483566=1131812513441120256; "
        "share_source_origin=copy_web; bsource=share_source_copylink_web; bmg_af_switch=1; "
        "bmg_src_def_domain=i1.hdslb.com; sid=5byg1rh3; b_lsid=AC4E1510F_19A5E511428; CURRENT_FNVAL=4048"
    )
    
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
        "Referer": "https://www.bilibili.com/",  # 伪装成从B站首页过来的
        "Cookie": cookie_str
    }

    print("开始搜索视频 BVID (API 模式)...")
    
    # 循环翻页，直到达到 max_videos 或没有更多结果
    while len(bvid_list) < max_videos:

        # API 的查询参数
        params = {
            "keyword": keyword,
            "search_type": "video",  # 指定搜索类型为"视频"
            "page": page,
        }

        try:
            print(f"正在搜索第 {page} 页... (已找到 {len(bvid_list)} / {max_videos})")

            response = requests.get(search_api_url, headers=headers, params=params, timeout=10)
            response.raise_for_status()
            data = response.json()

            # 检查 API 返回是否成功
            if data.get("code") != 0:
                print(f"API返回错误 (Code: {data.get('code')}): {data.get('message')}")
                print("!! 可能是 Cookie 失效或 IP 被临时限制，停止搜索。")
                break

            # 解析 JSON 数据 (使用 .get 确保键不存在时返回默认值)
            video_results = data.get("data", {}).get("result", [])

            if not video_results:
                print(f"第 {page} 页没有找到更多视频，搜索结束。")
                break  # API 没有返回更多结果

            new_bvids_found_on_page = 0
            for item in video_results:
                # API 返回的 JSON 里直接就有 bvid
                bvid = item.get("bvid")

                if bvid and bvid not in bvid_list:
                    bvid_list.append(bvid)
                    new_bvids_found_on_page += 1
                    print(f" -> 找到 BVID: {bvid}")
                    if len(bvid_list) >= max_videos:
                        break  # 已经找够了

            if new_bvids_found_on_page == 0 and video_results:
                print(f"第 {page} 页的视频均已重复，继续翻页...")

            if len(bvid_list) >= max_videos:
                print(f"已成功收集 {len(bvid_list)} 个 BVID。")
                break

            page += 1  # 准备搜下一页

            # !! 关键：搜索翻页延时，防止被 B 站风控 !!
            print(f" -> 延时 {search_delay_seconds} 秒...")
            time.sleep(search_delay_seconds)

        except Exception as e:
            print(f"搜索第 {page} 页时出错: {e}。停止搜索。")
            break

    return bvid_list[:max_videos]


# --- 主程序入口 ---
if __name__ == "__main__":

    # --- 参数配置 ---
    SEARCH_KEYWORD = "大数据模型"       # 关键词
    MAX_VIDEOS_TO_SCRAPE = 5          # 目标爬取的视频数量
    SEARCH_PAGE_DELAY = 2           # 搜索 API 翻页延时 (秒)
    DANMAKU_FETCH_DELAY = 3         # 爬取每个视频弹幕的延时 (秒)
    
    # --- 变量初始化 ---
    all_cleaned_danmaku = []          # 用于存储所有视频的弹幕

    # 使用一个大的 try-except 块包裹主流程
    try:
        # --- 步骤 1：搜索并获取 BVID 列表 ---
        bvid_list = search_for_videos(
            SEARCH_KEYWORD, 
            MAX_VIDEOS_TO_SCRAPE, 
            SEARCH_PAGE_DELAY
        )
        
        print(f"\n--- 搜索完成，共找到 {len(bvid_list)} 个视频 BVID ---")
        
        if not bvid_list:
            # 如果 BVID 列表为空，说明搜索失败，直接退出
            raise Exception("未能搜索到任何视频 BVID，程序退出。")

        # --- 步骤 2：循环爬取所有视频的弹幕 ---
        print(f"开始循环爬取 {len(bvid_list)} 个视频的弹幕，这将需要一些时间...")

        for i, bvid in enumerate(bvid_list, 1):
            
            # (重要) 内部 try-except-finally 块：
            # 确保单个视频爬取失败 (如视频已删除、API错误) 不会导致整个程序崩溃。
            try:
                print(f"\n[进度: {i}/{len(bvid_list)}] 正在处理: {bvid}")

                # 2.1 获取 CID
                cid = get_cid(bvid)
                print(f" -> CID: {cid}")

                # 2.2 获取弹幕
                danmaku_list = fetch_danmaku(cid, bvid)

                # 2.3 清洗弹幕
                # 使用列表推导式，同时过滤掉清洗后为空的字符串
                cleaned_danmaku = [clean_text(d) for d in danmaku_list if clean_text(d)]
                print(f" -> 成功获取 {len(cleaned_danmaku)} 条有效弹幕。")

                # 2.4 存入总列表
                all_cleaned_danmaku.extend(cleaned_danmaku)

            except Exception as e:
                # 捕获 get_cid 或 fetch_danmaku 中抛出的异常
                print(f" !! 爬取 {bvid} 时出错: {e}。跳过此视频。")
            
            finally:
                # !! 关键：无论成功与否，都执行延时，防止 IP 被封禁 !!
                # (如果只爬取少量视频，可以适当调低此延时)
                print(f" -> 延时 {DANMAKU_FETCH_DELAY} 秒...")
                time.sleep(DANMAKU_FETCH_DELAY)

        # --- 步骤 3：所有爬取结束，进行聚合分析 ---
        print(f"\n--- 所有视频爬取完毕 ---")
        print(f"总共收集到 {len(all_cleaned_danmaku)} 条有效弹幕。")

        if not all_cleaned_danmaku:
            print("未收集到任何有效弹幕，无法进行分析，程序结束。")
        else:
            print("开始进行聚合分析和数据导出...")
            
            # 动态生成文件名
            file_prefix = f"{SEARCH_KEYWORD}_{MAX_VIDEOS_TO_SCRAPE}个视频"
            
            # 3.1 保存所有弹幕
            save_danmaku_to_file(
                all_cleaned_danmaku, 
                f"{file_prefix}_聚合弹幕数据.txt"
            )

            # 3.2 保存词频 (TXT)
            save_word_freq_to_txt(
                all_cleaned_danmaku, 
                f"{file_prefix}_聚合词频数据.txt"
            )

            # 3.3 保存词频 (Excel)
            save_word_freq_to_excel(
                all_cleaned_danmaku, 
                f"{file_prefix}_聚合词频数据.xlsx"
            )

            # 3.4 生成词云
            generate_wordcloud(
                all_cleaned_danmaku, 
                f"{file_prefix}_聚合词云.jpg"
            )

        print("\n--- 全部任务完成 ---")

    except Exception as e:
        # 捕获主流程中的致命错误 (例如搜索失败、权限问题)
        print(f"\n程序运行中发生致命错误: {str(e)}")